Skip to content

audio logging and diagnostics with structlog - #47

Open
jquast wants to merge 8 commits into
vxgmichel:mainfrom
jquast:jq/audio-fixes
Open

audio logging and diagnostics with structlog#47
jquast wants to merge 8 commits into
vxgmichel:mainfrom
jquast:jq/audio-fixes

Conversation

@jquast

@jquast jquast commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Replace the TODO stubs for underrun/overrun logging and add per-frame audio telemetry via structlog.

Add --logfile / --loglevel / --logfmt on all entry points (local, SSH, telnet), this is copied from telnetlib3. This is tricky because we really shouldn't log anything when run locally unless --logfile is also used .. so I have changed loglevel to default to critical for local execution, and info for ssh/telnet.

jquast added 4 commits July 15, 2026 13:35
- Add GAMBATERM_AUDIO_LOG and GAMBATERM_AUDIO_CSV to report statistics,
  replacing some 'TODO' items about logging under and overruns.

- Adds common 'fill_fraction' property, to determine

- Changed to a batch variable-length emulator audio in send().

  runFor() may return only a few hundred samples at video-frame
  boundaries; feeding those directly to the resampler can starve the
  ring buffer, instead, accumulate until the batch is large enough
  prevents this.

  In practicality, an underrun only occurs when the system is under
  load, I have only induced a starved ring buffer by using too much cpu
  by encoding kitty graphics in render(). I plan to address this by
  "banding", but I think this fix can be helpful for lower end computers
  or when the system is under load?
Prevent fill_fraction from returning a negative value if read_counter
ever exceeds write_counter (should never happen, but this is a cheap
safety net).
Comment thread gambaterm/audio.py Outdated
Comment on lines +233 to +236
# Accumulate input to batch variable-length emulator frames into consistent chunks for the
# resampler. The emulator's runFor() may produce anywhere from a few hundred to tens of
# thousands of samples per call; tiny batches could otherwise starve the ring buffer when
# under load.

@vxgmichel vxgmichel Jul 24, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I introduced the circular buffer, I had in mind that variable-length audio batches would not be a problem since I made sure that the core loop itself is synchronized with the number of produced audio samples:

# Timing sync
increment = samples / console.TICKS_IN_FRAME
deadline = start + increment / fps
current = time.time()
if current < deadline - 1e-3:
time.sleep(deadline - current)
# Use deadline as new reference to prevent shifting
shifting.append(time.time() - deadline)
start = deadline

So in theory, the producer (i.e gambatte) and the consumer (miniaudio) should move at the exact same rate. The reason why they drift appart is because the producer uses the cpu clock, and the consumer the audio clock. So the job of the PI controller is to detect this slow drift and tune the resample rate to compensate for it. Since the drift is slow, monitoring a rolling average of the fill ratio is a good way to detect and adapt to the drift.

So as far as I can tell, tiny audio batches should not starve the ring buffer since they should be produced faster to compensate (unless the producing of the video frames at a faster rates slows down the core loop, although this should already be accounted for)

Do you think my analysis is correct? Did I miss something?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can only induce a starved audio buffer by spending too much CPU in rendering, the text mode blitter doesn't consume enough CPU to show the effect. In any case, I will change the render logic there instead, from "always render at least one partial graphics frame", to "skip rendering this frame all together when fill_fraction is low, to allow subsequent frames to fill it back up again before I take too much CPU time rendering it"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will change the render logic there instead, from "always render at least one partial graphics frame", to "skip rendering this frame all together when fill_fraction is low

I already implemented some logic to detect when the main loop can't keep up with the clock, and skip rendering+outputting the video frame if that's the case:

# Use deadline as new reference to prevent shifting
shifting.append(time.time() - deadline)
start = deadline

# Detect if a shift is currently happening
shift = shifting and shifting[-1] > 1 / fps
# Render a new frame only if:
# - it is the right time according to frame_advance
# - a new frame is available from the emulator
# - the screen is ready for a new frame (either CPR sync is disabled, or enabled and we received the CPR response)
# - we are not currently shifting (to prevent flooding the terminal with new frames when the rendering is too slow)
if i % frame_advance == 0 and new_frame and screen_ready and not shift:

It's too bad that it's not enough to keep the audio circular buffer half-full. There are other solutions we can try in order to fix this:

Comment thread gambaterm/main.py
logging.basicConfig(**_cfg)
logging.getLogger().setLevel(lvl)
logging.getLogger(name).setLevel(lvl)
return logging.getLogger(name)

@vxgmichel vxgmichel Jul 24, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added structlog to the project dependencies in my last PR (#46):
7cc93e0

Maybe you'll find it easier to work with :)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh right, I will wire it there, I will have to look at structlog to see what feature is used or useful instead of standard logging

@jquast jquast changed the title Variable-length audio buffering and logging audio logging and diagnostics with structlog Jul 24, 2026
@vxgmichel

Copy link
Copy Markdown
Owner

This is tricky because we really shouldn't log anything when run locally unless --logfile is also used .. so I have changed loglevel to default to critical for local execution, and info for ssh/telnet.

Can't we simply force a logfile and never log to the console when running locally? Maybe we could make this directory cross-platform and put the log file in there, overwriting it on each run?

gambaterm_config_dir = Path(
os.environ.get("GAMBATERM_CONFIG_DIR", "~/.config/gambaterm")
).expanduser()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants